Skip to content

Perf #862: promote non-escaping object literals to generated shape structs#867

Merged
nickna merged 2 commits into
mainfrom
wrk/issue-862-object-shape-struct
Jun 20, 2026
Merged

Perf #862: promote non-escaping object literals to generated shape structs#867
nickna merged 2 commits into
mainfrom
wrk/issue-862-object-shape-struct

Conversation

@nickna

@nickna nickna commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #862 (perf epic #856 child). In compiled mode an object literal compiled to a Dictionary<string,object> — each field write a string-keyed set_Item with the value boxed, each read an isinst Dictionary -> TryGetValue -> ConvertToNumber. On the objects benchmark ({ x:i, y:i+1 }) that was ~14x slower than Node even though the shape and field types are statically known.

A provably non-escaping const/let object literal with a fixed primitive shape is now promoted to a generated value-type $Shape_N struct with typed fields (number->double, boolean->bool, string->string). o.x reads/writes lower to direct ldfld/stfld — no Dictionary, no string hash, no boxing — and a non-escaping struct local is register-promoted by the JIT, so the hot loop becomes pure unboxed double arithmetic.

This is the third instance of the #857/#858 conservative non-escaping-local promotion pattern, and is fully additive: any escape (passed / returned / spread / === / captured / o[expr] / compound-assign / enumerated / non-primitive or undefined-admitting field) disqualifies the local and falls back to the existing Dictionary path, so the object-semantics surface (descriptors, freeze, Object.keys, spread, delete) is untouched.

Performance

Best-of-5, warmed, objectWork(20,000,000), identical checksum (4e14):

Build Hot-loop time
Before (Dictionary<string,object>) ~747 ms
After ($Shape struct) ~25 ms

~31x faster on the exact issue workload. The loop emits zero Dictionary/box/GetProperty and IL-verifies; the codegen class now matches factorial (pure unboxed double), which already beats Node — so compiled objects should meet/exceed Node, the epic goal.

How it works

  • ObjectLocalPromotionAnalyzer — whole-program escape analysis (mirrors ArrayLocalPromotionAnalyzer / NonEscapingArrowLocalAnalyzer): candidate = const/let declared once, simple object literal (plain IdentifierKey: value, unique keys, every field a primitive), used only as o.KEY read/write; not captured.
  • TypeMap carries the pure-data ObjectShapeInfo; ObjectShapeRegistry holds the generated TypeBuilders, threaded through the CompilationContext construction sites like DisplayClasses / DirectCallArrowBindings.
  • DefineObjectShapeTypes emits one $Shape_N per distinct shape (BCL-only fields, so standalone output is preserved — no SharpTS.dll dependency); declaration + property get/set fast paths in ILEmitter. The object get/set fast paths precede the TypeInfo.Record Dictionary path (a promoted local is Record-typed but its slot is a struct).
  • Deferred (fall back, documented follow-ups): compound member-assign (o.x += v), async/generator-body locals, nested literals, the escaping reference-POCO tier.

Testing

  • Build clean; emitted IL for objectWork is a pure $Shape_0 struct (0 Dictionary/box/GetProperty), IL-verifies, correct output; standalone DLL runs with no SharpTS.dll present.
  • +17 dual-mode tests (SharpTS.Tests/SharedTests/ObjectLocalPromotionTests.cs) covering promotion + the full escape matrix.
  • Green (interpreter + compiled): Object/Record/Property/Freeze/Spread/ForIn, Compiler/Closure/Loop, Generator/Async, Module/Import/Standalone (~7,872 tests, 0 failures).

Interpreter and type-checker are untouched, so Test262-interpreter and TypeScript-conformance results are unaffected by this change. (Note: the committed Test262 baselines are pre-existing stale/flaky on main and the in-process compiled Test262 run exhausts the test host — unrelated to this PR.)

nickna added 2 commits June 20, 2026 13:41
…ructs

A compiled object literal compiled to a Dictionary<string,object>: each field
write a string-keyed set_Item with the value boxed, each read an
isinst Dictionary -> TryGetValue -> ConvertToNumber. On the objects benchmark
({ x:i, y:i+1 }) that is ~14x slower than Node despite the shape and field types
being statically known.

A provably non-escaping const/let object literal with a fixed primitive shape is
now promoted to a generated value-type $Shape_N struct with typed fields
(number->double, boolean->bool, string->string). o.x reads/writes lower to direct
ldfld/stfld -- no Dictionary, no string hash, no boxing -- and a non-escaping
struct local is register-promoted by the JIT, so the hot loop becomes pure
unboxed double arithmetic. objectWork(20M): ~747ms -> ~25ms (~31x), identical
output; the loop emits zero Dictionary/box/GetProperty and IL-verifies.

Third instance of the #857/#858 conservative non-escaping-local promotion
pattern, and fully additive: any escape (passed/returned/spread/===/captured/
o[expr]/compound-assign/enumerated/non-primitive or undefined-admitting field)
disqualifies the local and falls back to the existing Dictionary path, so the
object-semantics surface (descriptors, freeze, Object.keys, spread, delete) is
untouched.

- ObjectLocalPromotionAnalyzer: whole-program escape analysis (mirrors
  ArrayLocalPromotionAnalyzer / NonEscapingArrowLocalAnalyzer).
- TypeMap carries the pure-data ObjectShapeInfo; ObjectShapeRegistry holds the
  generated TypeBuilders, threaded through the CompilationContext construction
  sites like DisplayClasses / DirectCallArrowBindings.
- DefineObjectShapeTypes emits one $Shape_N per distinct shape (BCL-only fields,
  so standalone output is preserved); declaration + property get/set fast paths
  in ILEmitter. The object get/set fast paths precede the TypeInfo.Record
  Dictionary path (a promoted local is Record-typed but its slot is a struct).

Compound member-assign (o.x += v) and async/generator-body locals are deferred
(they fall back). +17 dual-mode tests; Object/Record/Property/Freeze/Spread/
ForIn, Compiler/Closure/Loop, Generator/Async, Module/Import/Standalone green.
Resolve ILEmitter.Statements.cs EmitVarDeclaration conflict with the #858
follow-up (non-capturing local-arrow direct static call): keep both
mutually-exclusive promotion branches — the non-capturing arrow branch (its
comment refers to the capturing branch above) followed by the #862 object-literal
shape-struct branch, each before the typed-array branch.
@nickna nickna closed this Jun 20, 2026
@nickna nickna reopened this Jun 20, 2026
@nickna nickna merged commit ff62855 into main Jun 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Perf: object literals use Dictionary<string,object> instead of hidden-class shapes

1 participant